CAS单点登录、单点退出|nginx下多个应用服务器问题的解决
背景
系统内有多个应用,例如:admin、callcenter、frontweb、phone等,每个应用有多个运行的实例,例如:admin1、admin2、frontweb1、frontweb2、frontweb3等。同一个应用的实例可能分布在不同的实体机器上,也可能在相同的机器上,每一个实例就是一个tomcat。但是,所有访问必须先通过nginx,由它决定最终要访问哪个tomcat。
现在,要增加单点登录和单点退出的功能,即在一个应用上登录后,在其他应用不需要重复登录了。
遇到的问题
按照网上的大多数流程配置完成后,在本地调试可以正常运行,也看不出什么问题,因为本地没有多个应用的实例,并且本地也没有配置nginx。
但是在真实环境下,有多个admin和多个callcenter应用实例,单点登录没问题,但是当用户在admin退出后,之前登录的callcenter依旧可以正常使用,出问题了。
问题原因
经查实,原因出在logout时,由于nginx的存在,cas服务器发送的单点退出请求,没有经过nginx的转发到达用户登录的callcenter服务器上,而nginx也不确定这个请求应该转发到哪一台服务器上。所以退出失败。
CAS单点登出原理和解决办法
用户在登录admin时,会在CAS服务器使用该域名注册,以表示该用户在admin已经登录。同理,同一用户同一浏览器在登录callcenter时,也会在CAS服务器注册。当用户在其中一点退出后,CAS服务器会向在它上面注册过的所有应用发送退出请求。
访问应用时,使用域名访问,通过nginx,但是注册
时使用应用所在的ip和端口号。退出请求也要使用ip和端口号,不通过nginx。
以下从代码角度进行分析
Login flow(I mean the”login-webflow.xml”) does not contain all the login process. It onlyinclude the step where the browser is redirected to app with ST. BUT the STcheck URL can’t be found in thelogin-webflow.xml. So refer to “cas-servlet.xml” which involved allthe beans CAS used.
在原始ServiceProperties中service参数是通过xml文件配置的。如果用以域名为开头的URL作为service参数。并且系统中每个应用有两个实例(每个实例可以看做是一个Tomcat),且这些应用是被放在nginx后面,每次访问都会经由nginx判断,从而最终选择那台服务器作为真正使用的服务器。理想的逻辑结构如下图。
这样的架构对于单点登录没有影响,但是想要实现单点退出,却是要费一番脑经的。按照已有的架构,当用户在多个应用登陆成功时,每个应用都在CAS服务器进行注册。此时用户想要退出,需要在一个应用点击退出按钮,则该应用的所在的实例的session被销毁,并且将浏览器重定向到CAS服务器,通知CAS进行退出操作,待CAS完成退出操作后,就会向已经在CAS服务器注册过的每个应用发送退出请求,每个应用拦截该请求,完成退出登陆操作。
等等,让我们在回到“CAS服务器向注册过的每个应用发送退出请求”这一步,针对现有架构,每一次访问都经过nginx,所以,CAS服务器在向每一个应用发送退出请求时,也是使用域名实现的,但是当nginx遇到以域名为开头的退出请求时,他不能准确指出,这个请求该发给哪个实例,不该发给哪个实例。因为,根据nginx的判断配置来看,是根据随请求携带的session来判断请求的转发目标的,而登录的时候,用户是通过浏览器访问应用的,但此时,访问该应用的是CAS服务器,所以,nginx不能准确判断该请求的转发目标,所以,单点退出失败。
究其原因,是由登录流程引起的。从网上可以找到CAS单点登录的协作图,如下:
在实际操作中,可能是因为代码的版本问题(具体情况不明确),针对上图做了一些修改,如下。
logout时,CAS发出的请求的路径是在登录流程中设置的。研究代码,debug跟踪后,可以了解到改地址就是登录流程中的service参数。其设置是在第一次访问应用,并且重定向到CAS login界面时确定的|其设置实在用户输入用户名密码登陆成功后产生的|是在用户验证ST时,通过request产生的。
针对以上描述情况,给ServiceProperties的service参数直接配置以域名开头的URL,不能解决单点退出。试想,如果不适用nginx,是不是就可以解决问题,把service URL中的域名改为真实的ip和端口号,以解决单点退出的问题。但是考虑到上图步骤3处,service地址需要重定向,局域网地址浏览器不能正常访问,所以获取tomcat的真实ip和端口号当做service参数的一部分。于是有了下图结构。
这样设置时可以正常工作的,因为在步骤二时记录的service地址是ip和端口号,所以在logout时,CAS发出的请求不会通过nginx,直接根据ip和端口号进行访问。但是由于步骤3重定向地址是ip地址,所以登录成功后,用户浏览器地址栏也是ip和端口,美观不说还影响安全。此方案不可行。
But
试想是否可以让步骤3和logout时CAS请求的地址不同,比如,步骤三的地址使用域名,而logout时CAS请求的地址ip和端口号,而且对于CAS来说,其它应用在局域网内,这样就解决了问题。于是在上图的情况下稍微做些修改。
根据协作图可知,步骤二的重定向地址由casEntryPoint也就是默认配置中的CasAuthenticationEntryPoint
类进行计算得出,要想让CAS同时了解域名和ip,就必需在重定向地址中增加参数。为此继承CasAuthenticationEntryPoint
而得CasTargetUrlAuthenticationEntryPoint
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46package com.xxxx.webapp.common.security.cas;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import org.springframework.security.cas.web.CasAuthenticationEntryPoint;
import com.xxxxx.framework.common.log.Log;
public class CasTargetUrlAuthenticationEntryPoint extends
CasAuthenticationEntryPoint {
private final static Log LOG = Log.getLog(CasTargetUrlAuthenticationEntryPoint.class);
// targetUrl 代表用户访问的域名
private String targetUrl;
protected String createRedirectUrl(String serviceUrl) {
StringBuffer sb = new StringBuffer();
sb.append(targetUrl).append("/").append(((CasServiceProperties)getServiceProperties()).getFilterProcessesUrl());
String encodedOtherParameterValue = null;
try {
encodedOtherParameterValue = URLEncoder.encode(sb.toString(), "UTF-8");
} catch (UnsupportedEncodingException e) {
LOG.error("Cas login redirect url encode failed", e);
}
String redirectUrl = super.createRedirectUrl(serviceUrl);
StringBuffer redirectUrlsb = new StringBuffer();
redirectUrlsb.append(redirectUrl).append("&").append("targetUrl").append("=").append(encodedOtherParameterValue);
redirectUrl = redirectUrlsb.toString();
return redirectUrl;
}
public String getTargetUrl() {
return targetUrl;
}
public void setTargetUrl(String targetUrl) {
this.targetUrl = targetUrl;
}
}
为了能让ip和端口号自动获取,所以ServiceProperties
中的service参数不能用配置文件配置,所以继承ServiceProperties
而得 CasServiceProperties
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84package com.xxxxx.webapp.common.security.cas;
import java.lang.management.ManagementFactory;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.Set;
import javax.management.AttributeNotFoundException;
import javax.management.InstanceNotFoundException;
import javax.management.MBeanException;
import javax.management.MBeanServer;
import javax.management.MalformedObjectNameException;
import javax.management.ObjectName;
import javax.management.Query;
import javax.management.ReflectionException;
import org.apache.commons.lang.StringUtils;
import org.springframework.security.cas.ServiceProperties;
import com.xxxxx.framework.common.log.Log;
public class CasServiceProperties extends ServiceProperties {
private final static Log LOG = Log.getLog(CasServiceProperties.class);
private final static String DEFAULT_LOCAL_IP = "127.0.0.1";
private final String filterProcessesUrl;
public CasServiceProperties(String filterProcessesUrl) throws MalformedObjectNameException, NullPointerException,
UnknownHostException, AttributeNotFoundException, InstanceNotFoundException,
MBeanException, ReflectionException, SocketException {
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
Set<ObjectName> objs = mbs.queryNames(new ObjectName("*:type=Connector,*"),
Query.match(Query.attr("protocol"), Query.value("HTTP/1.1")));
LOG.debug("Objs size : {}", objs.size());
String port = null;
for (Iterator<ObjectName> i = objs.iterator(); i.hasNext(); ) {
ObjectName obj = i.next();
port = obj.getKeyProperty("port");
if (StringUtils.isNotBlank(port)) {
break;
}
}
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
String tomcatIp = DEFAULT_LOCAL_IP;
while (networkInterfaces.hasMoreElements()) {
NetworkInterface networkInterface = (NetworkInterface) networkInterfaces.nextElement();
Enumeration<InetAddress> inetAddresses = networkInterface.getInetAddresses();
while (inetAddresses.hasMoreElements()) {
InetAddress inetAddress = (InetAddress) inetAddresses.nextElement();
if (inetAddress.isSiteLocalAddress()) {
tomcatIp = inetAddress.getHostAddress();
LOG.debug("Web server ip : {}.", tomcatIp);
break;
}
}
}
this.filterProcessesUrl = filterProcessesUrl;
StringBuffer sb = new StringBuffer();
sb.append("http://").append(tomcatIp).append(":").append(port).append("/")
.append(filterProcessesUrl);
String finalServceUrl = sb.toString();
LOG.debug("Service url : {}", finalServceUrl);
setService(finalServceUrl);
}
public String getFilterProcessesUrl() {
return filterProcessesUrl;
}
}
最终的CAS Client的配置文件如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107<security:global-method-security />
<security:http auto-config="true"
entry-point-ref="casEntryPoint"
access-denied-page="/403.jsp"
use-expressions="true"
access-decision-manager-ref="accessDecisionManager">
<security:custom-filter ref="requestSingleLogoutFilter" before="LOGOUT_FILTER"/>
<security:custom-filter ref="singleLogoutFilter" before="CAS_FILTER"/>
<security:custom-filter ref="casProcessingFilter" position="CAS_FILTER" />
<security:intercept-url pattern="/assets/**" access="permitAll" />
<security:intercept-url pattern="/debug/**" access="permitAll" />
<security:intercept-url pattern="/favicon.ico" access="permitAll" />
<security:intercept-url pattern="/LogServlet" access="permitAll" />
<security:intercept-url pattern="/security/**" access="permitAll" />
<security:intercept-url pattern="/services/rs/**" access="permitAll" />
<security:intercept-url pattern="/**" access="isAuthenticated()" />
<security:logout logout-url="/security/logout.html" invalidate-session="false" success-handler-ref="logoutSuccessHandler"/>
<security:session-management
session-fixation-protection="none" />
<!-- if enabled org.springframework.security.web.session.HttpSessionEventPublisher must be added into web.xml -->
<security:session-management>
<security:concurrency-control max-sessions="1"
error-if-maximum-exceeded="true" />
</security:session-management>
</security:http>
<!-- This filter redirects to the CAS Server to signal Single Logout should be performed -->
<bean id="requestSingleLogoutFilter" class="org.springframework.security.web.authentication.logout.LogoutFilter">
<constructor-arg value="${cas.server.url}/logout"/>
<constructor-arg>
<bean class="org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler">
</bean>
</constructor-arg>
<property name="filterProcessesUrl" value="/j_spring_cas_security_logout"/>
</bean>
<!-- This filter handles a Single Logout Request from the CAS Server -->
<bean id="singleLogoutFilter" class="org.jasig.cas.client.session.SingleSignOutFilter"/>
<bean id="logoutSuccessHandler"
class="com.xxxxx.webapp.common.security.employee.authn.DefaultLogoutSuccessHandler">
<property name="defaultTargetUrl" value="/j_spring_cas_security_logout"></property>
<property name="alwaysUseDefaultTargetUrl" value="true"></property>
</bean>
<bean id="casEntryPoint" class="com.xxxxx.webapp.common.security.cas.CasTargetUrlAuthenticationEntryPoint">
<property name="loginUrl" value="${cas.server.url}/login"/>
<property name="serviceProperties" ref="casServiceProperties"/>
<property name="targetUrl" value="${platform.admin.server.url}"></property>
</bean>
<bean id="casServiceProperties" class="com.xxxxx.webapp.common.security.cas.CasServiceProperties">
<constructor-arg value="${platform.cas.login.filter.processes.url}"/>
<property name="sendRenew" value="false"/>
<property name="authenticateAllArtifacts" value="true"/>
</bean>
<bean id="casProcessingFilter" class="org.springframework.security.cas.web.CasAuthenticationFilter">
<property name="authenticationManager" ref="authenticationManager" />
<property name="authenticationFailureHandler">
<bean class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
<property name="defaultFailureUrl" value="/casfailed.jsp" />
</bean>
</property>
<property name="authenticationSuccessHandler">
<bean class="com.xxxxx.webapp.common.security.employee.authn.DefaultAuthenticationSuccessHandler"/>
</property>
<property name="filterProcessesUrl" value="/${platform.cas.login.filter.processes.url}"></property>
</bean>
<security:authentication-manager alias="authenticationManager">
<security:authentication-provider ref="casAuthenticationProvider" />
</security:authentication-manager>
<bean id="casAuthenticationProvider" class="org.springframework.security.cas.authentication.CasAuthenticationProvider">
<property name="authenticationUserDetailsService">
<!-- <bean class="org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper">
<constructor-arg ref="userService" />
</bean> -->
<bean class="com.xxxxx.webapp.common.security.cas.CasUserDetailService" />
</property>
<property name="serviceProperties" ref="casServiceProperties"></property>
<property name="ticketValidator">
<bean class="org.jasig.cas.client.validation.Cas20ServiceTicketValidator">
<constructor-arg index="0" value="${cas.server.url}"/>
</bean>
</property>
<property name="key" value="security"/>
</bean>
<bean id="accessDecisionManager"
class="org.springframework.security.access.vote.AffirmativeBased">
<property name="decisionVoters">
<list>
<bean class="org.springframework.security.web.access.expression.WebExpressionVoter" />
<bean class="com.xxxxxx.webapp.common.security.employee.authz.DefaultApplicationPermissionVoter" />
</list>
</property>
</bean>
CAS 服务端
在验证ST的开始,需要从request中获取service对象,默认调用关系为:
org.jasig.cas.web.ServiceValidateController.handleRequestInternal(HttpServletRequest,HttpServletResponse)——>org.jasig.cas.web.support.AbstractArgumentExtractor.extractService(HttpServletRequest)———>org.jasig.cas.web.support.CasArgumentExtractor.extractServiceInternal(HttpServletRequest)——>org.jasig.cas.authentication.principal.SimpleWebApplicationServiceImpl.createServiceFrom(HttpServletRequest)
但是默认流程不能处理我们在CAS Client端加的targetUrl参数。最终的service对象就是SimpleWebApplicationServiceImpl,而它不能扩展,所以复制这个类,增加自己的变量。如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.jasig.cas.authentication.principal.AbstractWebApplicationService;
import org.jasig.cas.authentication.principal.Response;
import org.jasig.cas.authentication.principal.Response.ResponseType;
import org.springframework.util.StringUtils;
public class TargetUrlWebApplicationServiceImple extends AbstractWebApplicationService {
private static final String CONST_PARAM_SERVICE = "service";
private static final String CONST_PARAM_TARGET_SERVICE = "targetService";
private static final String CONST_PARAM_TICKET = "ticket";
private static final String CONST_PARAM_METHOD = "method";
private static final String CONST_PARAM_ORIGIN_TARGET_URL = "targetUrl";
private final ResponseType responseType;
private String targetUrl;
private static final long serialVersionUID = 8334068957483758042L;
public TargetUrlWebApplicationServiceImple(final String id) {
this(id, id, null, null, null);
}
private TargetUrlWebApplicationServiceImple(final String id,
final String originalUrl, final String artifactId,
final ResponseType responseType, String targetUrl) {
super(id, originalUrl, artifactId);
this.responseType = responseType;
this.targetUrl = targetUrl;
}
public static TargetUrlWebApplicationServiceImple createServiceFrom(
final HttpServletRequest request) {
final String targetService = request
.getParameter(CONST_PARAM_TARGET_SERVICE);
String targetUrl = request
.getParameter(CONST_PARAM_ORIGIN_TARGET_URL);
final String method = request.getParameter(CONST_PARAM_METHOD);
final String serviceToUse = StringUtils.hasText(targetService)
? targetService : request.getParameter(CONST_PARAM_SERVICE);
if (!StringUtils.hasText(serviceToUse)) {
return null;
}
final String id = cleanupUrl(serviceToUse);
final String artifactId = request.getParameter(CONST_PARAM_TICKET);
return new TargetUrlWebApplicationServiceImple(id, serviceToUse,
artifactId, "POST".equals(method) ? ResponseType.POST
: ResponseType.REDIRECT, targetUrl);
}
public Response getResponse(final String ticketId) {
final Map<String, String> parameters = new HashMap<String, String>();
if (StringUtils.hasText(ticketId)) {
parameters.put(CONST_PARAM_TICKET, ticketId);
}
if (ResponseType.POST == this.responseType) {
return Response.getPostResponse(targetUrl, parameters);
}
return Response.getRedirectResponse(targetUrl, parameters);
}
但是其调用类CasArgumentExtractor
的方法也不能扩展,所以复制该类,更改为调用自己的实现类TargetUrlCasArgumentExtractor
。1
2
3
4
5
6
7
8
9
10
11
12import javax.servlet.http.HttpServletRequest;
import org.jasig.cas.authentication.principal.WebApplicationService;
import org.jasig.cas.web.support.AbstractArgumentExtractor;
public class TargetUrlCasArgumentExtractor extends AbstractArgumentExtractor {
public WebApplicationService extractServiceInternal(final HttpServletRequest request) {
return TargetUrlWebApplicationServiceImple.createServiceFrom(request);
}
}
注意在uniqueIdGenerators.xml进行配置1
2
3
4
5
6
7
8<util:map id="uniqueIdGeneratorsMap">
<!-- <entry
key="org.jasig.cas.authentication.principal.SimpleWebApplicationServiceImpl"
value-ref="serviceTicketUniqueIdGenerator" /> -->
<entry
key="com.xxx.cas.webapp.authn.TargetUrlWebApplicationServiceImple"
value-ref="serviceTicketUniqueIdGenerator" />
</util:map>
那么在用户调用重定向地址时,我们返回的是域名,而最终在CAS中注册,并且logout返回的都是ip和端口号。
这样既能保证用户访问时,使用域名能够正常访问,还能保证一个用户在CAS注册时,使用的是ip,以至于当用户logout时,能够通过ip和端口号通知相应的服务器进行logout操作。